feat: add Claude Code CLI as an LLM provider (no API key)#61
feat: add Claude Code CLI as an LLM provider (no API key)#61huanghe wants to merge 1 commit intonashsu:mainfrom
Conversation
Users with a Claude Code subscription can now pick "Claude Code CLI
(local)" in settings and reuse their existing OAuth credentials instead
of pasting an Anthropic API key. The app spawns the local `claude`
binary as a subprocess, pipes the conversation in over stdin as
stream-json, and forwards stdout deltas back to the chat panel — same
StreamCallbacks contract as every HTTP provider, so the rest of the
app doesn't know which transport it's talking to.
Why subprocess instead of tauri-plugin-shell: the plugin's scope model
is designed for sidecars or fixed absolute paths; scoping a
user-installed PATH binary cleanly is awkward. A hardcoded Rust command
that always and only spawns `claude` provides the same security
property without pulling in another plugin or editing capabilities JSON.
Notable details:
- content must be an array of blocks ([{type:"text",text}]) on BOTH
user and assistant turns. A raw string works for single-turn user
input but crashes the CLI with `W is not an Object` once assistant
history is present, because it iterates blocks looking for tool_use_id.
- System messages are inlined into the first user turn rather than
passed via --system-prompt, since flag availability varies across
CLI versions.
- Sampling knobs (temperature, top_p, max_tokens, stop) have no CLI
equivalents and are silently ignored with a dev-only console warning.
- Settings UI shows a status pill that calls claude_cli_detect on
mount to confirm the binary is on PATH and runnable, with a macOS
Gatekeeper-quarantine remediation hint when that specific failure
is detected.
- stderr is bundled into the final :done event so non-zero exits
surface the real diagnostic ("exited with code 1: <stderr>") rather
than just an opaque exit code.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
Good feature. @nashsu, would you please give a review? |
|
Merged manually onto main as commit 63d8538 after rebasing past the v0.3.11–v0.3.13 changes (RAG pipeline, RRF retrieval, context budget rework, Origin header fix, etc.). GitHub marks this PR as "closed" rather than "merged" because the rebase changed the commit SHA, but your authorship is preserved on the merged commit and you'll show up in the project's Contributors list. Conflict resolution was minimal — just a couple of additions to the same Thanks for the careful design write-up and the security-conscious approach (hardcoded |
|
@ZiXuanVickyLu merged |
|
Appreciated! |
Summary
Adds a new LLM provider — Claude Code CLI (local) — that lets users with a Claude Code subscription pick Claude models in settings without pasting an Anthropic API key. The app spawns the local
claudebinary as a subprocess and streams the conversation over stdin/stdout asstream-json, so the OAuth credentials already in~/.claude/are reused and no key ever touches this app.The transport sits behind the same
StreamCallbackscontract as every HTTP provider, so the chat panel, cancellation (AbortSignal), and tokenized streaming all work identically — the rest of the code doesn't know which transport it's talking to.What changed
Rust (Tauri backend) — new module
src-tauri/src/commands/claude_cli.rs:claude_cli_detect— locatesclaudeon PATH, runsclaude --versionwith a 3s timeout, returns{installed, version, path, error}. Surfaces the macOS Gatekeeper quarantine remediation hint (xattr -d com.apple.quarantine …) when that specific failure is detected.claude_cli_spawn— spawnsclaude -p --output-format stream-json --input-format stream-json --verbose --model <model>, writes the serialized history to stdin (closing it so claude starts processing), and forwards each stdout line as a Tauri eventclaude-cli:{streamId}. stderr is collected and bundled into the finalclaude-cli:{streamId}:doneevent so non-zero exits surface the real diagnostic.claude_cli_kill— cancels a running subprocess for AbortSignal support.ClaudeCliState—HashMap<stream_id, Child>in a tokio Mutex; registered via.manage()inlib.rs.TS (frontend) — new file
src/lib/claude-cli-transport.ts:createClaudeCodeStreamParser()— parsesstream-jsonlines into token text. Handles bothstream_event(real Anthropic-API deltas, passthrough when--verboseis on) andassistantevents (full in-progress message on every emission). When both arrive, deltas are authoritative and the fatassistantevents are suppressed to avoid double-render. When onlyassistantevents arrive, emits the novel tail by prefix-diffing against what we've already shown.streamClaudeCodeCli()— wiresinvoke/listento the Rust commands, enforces the AbortSignal contract, and maps exit codes to user-facing errors. Sampling overrides (temperature/top_p/max_tokens/stop) have no CLI equivalents and are silently ignored with a dev-only console warning so callers don't silently wonder why they don't take effect.Dispatch —
src/lib/llm-client.tsroutesprovider === "claude-code"to the subprocess transport beforegetProviderConfig, which explicitly throws for this provider since it has no URL/headers.Settings UI —
llm-provider-section.tsxexcludesclaude-codefrom theneedsApiKeycheck (no key field shown) and renders aClaudeCliStatusPillthat callsclaude_cli_detecton mount so the user sees immediately whether the binary is installed, quarantined, or missing.Preset —
LLM_PRESETSgets aclaude-code-clientry withdefaultModel: "claude-sonnet-4-6"and the full Opus/Sonnet/Haiku lineup as suggestions. 200k context window.Notable details for reviewers
W is not an Object. (evaluating '"tool_use_id"in W')the moment assistant history appeared — the CLI iterates content blocks looking fortool_use_idand crashes on a raw string. User-only single-turn tolerates a string (that's the trap), so the Rust code normalizes both roles to[{type:"text",text:...}].--system-prompt/--append-system-prompt, because flag availability varies acrossclaudeCLI versions. Inlining works on every version.claudeprovides the same security property (the webview can't call this command to execute anything else) without pulling in another plugin or editingcapabilities/*.json.--resumesession state are all ignored. We useclaudepurely as a text-completion engine. Multi-turn history is reconstructed from themessagesarray on every call, symmetric with every other provider.Cargo.tomladdstokio(features: process, io-util, sync, macros, rt),which = "7", anduuid = "1"with v4.Test plan
src/lib/__tests__/claude-cli-transport.test.tscovers 9 parser scenarios (delta-only, assistant-only, delta-then-assistant dedupe, multi-part text, unknown event types, malformed JSON). All pass.getProviderConfig({provider:"claude-code"})throws — test added inllm-providers.test.ts.tsc --noEmitclean on frontend.cargo checkclean (only pre-existing warnings infs.rs)."Mango"→"Pineapple"for a fruit follow-up). This is how thecontent-must-be-array bug was found.claude, open Settings, pick "Claude Code CLI (local)", send a message, verify streaming tokens render.claude_cli_killterminates the subprocess.xattrremediation hint.🤖 Generated with Claude Code